import Link from "next/link"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { EventRow } from "@/components/event-row"; import { Badge, Chip, Empty, Flag, PageHeader, Panel, Score, Sparkline, StateBadge, Stat, TypeChip } from "@/components/ui"; import { ShareButton } from "@/components/watch-button"; import { api, SITE_URL, type ClusterDetail, type EventItem } from "@/lib/api"; import { fmtInt, fmtOffset, fmtScore, relTime, typeLabel, utcDate, utcDateTime, utcTime } from "@/lib/format"; export const dynamic = "force-dynamic"; function num(v: number | string | null | undefined): number { if (v === null || v === undefined) return 0; const n = typeof v === "string" ? Number(v) : v; return Number.isFinite(n) ? n : 0; } export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { const { slug } = await params; const d = await api.cluster(slug); if (!d) return { title: "Cluster not found" }; const c = d.cluster; const desc = (c.summary?.trim() || `${c.event_count} signal${c.event_count === 1 ? "" : "s"} from ${c.source_count ?? 1} source${(c.source_count ?? 1) === 1 ? "" : "s"} — ${d.first_party_signals} first-party, ${d.external_signals} external. Propagation timeline with WebSensor lead time.`).slice(0, 200); const path = `/cluster/${c.slug ?? c.id}`; return { title: c.title, description: desc, alternates: { canonical: path }, openGraph: { type: "article", title: c.title, description: desc, url: `${SITE_URL}${path}`, publishedTime: new Date(c.first_at).toISOString(), modifiedTime: new Date(c.last_at).toISOString(), tags: c.categories }, twitter: { card: "summary", title: c.title, description: desc }, }; } /** Signals per hour (or per day when the story spans more than 3 days) — computed from the events. */ function series(events: EventItem[], first: number, last: number): { values: number[]; unit: "hour" | "day" } { const span = Math.max(0, last - first); const unit: "hour" | "day" = span > 72 * 3600e3 ? "day" : "hour"; const step = unit === "hour" ? 3600e3 : 86400e3; const start = Math.floor(first / step) * step; const n = Math.min(120, Math.floor((last - start) / step) + 1); const values = new Array(Math.max(1, n)).fill(0); for (const e of events) { const i = Math.floor((new Date(e.detected_at).getTime() - start) / step); if (i >= 0 && i < values.length) values[i]! += 1; } return { values, unit }; } export default async function ClusterPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const d = await api.cluster(slug); if (!d) notFound(); const c = d.cluster; const path = `/cluster/${c.slug ?? c.id}`; const lead = num(d.lead_time_ms ?? c.lead_time_ms); const velocity = num(c.velocity); const firstMs = new Date(c.first_at).getTime(); const lastMs = new Date(c.last_at).getTime(); const spanMs = Math.max(0, lastMs - firstMs); const sources = sourcesOf(d); const countryOf = (p: ClusterDetail["propagation"][number]): string | null => (p.source as { country?: string | null }).country ?? null; const spark = series(d.events, firstMs, lastMs); const primary = d.events.find((e) => e.id === c.primary_event_id) ?? d.events[0]; const jsonLd = { "@context": "https://schema.org", "@type": "NewsArticle", headline: c.title, description: c.summary ?? undefined, datePublished: new Date(c.first_at).toISOString(), dateModified: new Date(c.last_at).toISOString(), url: `${SITE_URL}${path}`, mainEntityOfPage: `${SITE_URL}${path}`, author: { "@type": "Organization", name: "WebSensor", url: SITE_URL }, publisher: { "@type": "Organization", name: "WebSensor", url: SITE_URL }, about: d.entities.slice(0, 12).map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/entity/${x.id}` })), keywords: c.categories.join(", "), ...(primary?.url ? { isBasedOn: primary.url } : {}), }; return ( <>